| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- 'use client';
- import { useState, useEffect } from 'react';
- import { ChannelDetail, ChannelStatusUpdate } from '@/types/channel';
- import { useSignalRContext } from '@/contexts/signalrProvider';
- import { buildChannelUrl, formatHandle } from '@/lib/utils/channel';
- import YouTubeChatIframe from './YouTubeChatIframe';
- import ShareMenu from './ShareMenu';
- import FollowButton from '@/app/component/FollowButton';
- import Link from 'next/link';
- import './style.scss';
- // [DEPRECATED] antooza SignalR 채팅 — YouTube Live Chat iframe 으로 대체.
- // quota 승인 후 재활성화 예정. 자세한 작업 목록: memory/plan_dpot_chat_reactivate_after_quota.md
- // import ChatSidebar from './ChatSidebar';
- type Props = {
- channel: ChannelDetail;
- };
- function formatCount(n: number): string {
- if (n >= 10000) {
- const v = (n / 10000).toFixed(n >= 100000 ? 0 : 1);
- return `${v.replace(/\.0$/, '')}만명`;
- }
- return `${n.toLocaleString()}명`;
- }
- export default function WatchView({ channel }: Props)
- {
- const [descOpen, setDescOpen] = useState(false);
- const [isLive, setIsLive] = useState(channel.isLive);
- const [videoId, setVideoId] = useState<string|null>(channel.videoId);
- const [viewerCount, setViewerCount] = useState(channel.viewerCount);
- const [origin, setOrigin] = useState<string|null>(null);
- // ChannelStatusBroadcaster 는 AppHub 의 channel:{sid} 그룹으로 송출하므로 appConnection 사용 필수.
- // (chatConnection 으로 받으면 그룹 키 미스매치 + ChatHub.JoinChannel 의 입장 메시지 도배 부작용)
- const { appConnection } = useSignalRContext();
- // SSR 시점엔 window 가 없으므로 마운트 후 origin 해석
- useEffect(() => {
- if (typeof window !== 'undefined') {
- setOrigin(window.location.origin);
- }
- }, []);
- // SignalR 실시간 채널 상태 업데이트 (AppHub.ReceiveChannelStatus — 라이브 시작/종료, 시청자 수)
- useEffect(() => {
- if (!appConnection) {
- return;
- }
- const handler = (status: ChannelStatusUpdate) => {
- if (status.channelSID !== channel.channelSID) {
- return;
- }
- setIsLive(status.isLive);
- setVideoId(status.videoId);
- setViewerCount(status.viewerCount);
- };
- appConnection.on('ReceiveChannelStatus', handler);
- return () => {
- appConnection.off('ReceiveChannelStatus', handler);
- };
- }, [appConnection, channel.channelSID]);
- // ChannelStatusBroadcaster 가 AppHub 의 Clients.Group("channel:{sid}") 으로 송출 → AppHub.JoinChannel 로 가입.
- // AppHub.JoinChannel 은 순수 그룹 가입만 수행 (사이드이펙트 없음).
- // 재연결 시에도 자동 재가입.
- useEffect(() => {
- if (!appConnection) {
- return;
- }
- const sid = channel.channelSID;
- const join = async () => {
- if (appConnection.state !== 'Connected') {
- return;
- }
- try {
- await appConnection.invoke('JoinChannel', sid);
- } catch (err) {
- console.warn('[WatchView] JoinChannel 실패:', sid, err);
- }
- };
- join();
- appConnection.onreconnected(join);
- return () => {
- if (appConnection.state !== 'Connected') {
- return;
- }
- appConnection.invoke('LeaveChannel', sid).catch(() => {});
- };
- }, [appConnection, channel.channelSID]);
- // YouTube embed URL.
- // - youtube-nocookie.com: 임베드 제한이 youtube.com 대비 관대 (privacy-enhanced mode)
- // - origin / widget_referrer: 호스트 명시 → "다른 웹사이트에서 재생 차단" 케이스 일부 회피
- // - enablejsapi=1: origin 인식에 필요
- // - playsinline=1: iOS Safari 인라인 재생
- // 채널 소유자가 YouTube Studio에서 "임베드 허용"을 끈 경우는 클라이언트로 해결 불가.
- const embedUrl = videoId
- ? `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&mute=1&playsinline=1&enablejsapi=1${origin ? `&origin=${encodeURIComponent(origin)}&widget_referrer=${encodeURIComponent(origin)}` : ''}`
- : null;
- const shareTitle = channel.liveTitle || channel.name;
- const showViewers = isLive && viewerCount > 0;
- return (
- <div className="watch-page">
- <div className="watch-page__content">
- {/* 플레이어 */}
- <div className="watch-page__player">
- {embedUrl ? (
- <iframe
- src={embedUrl}
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
- allowFullScreen
- title={channel.liveTitle || channel.name}
- />
- ) : (
- <div className="watch-page__offline">
- <p>현재 방송 중이 아닙니다</p>
- <Link href={buildChannelUrl(channel)}>채널 페이지로 이동</Link>
- </div>
- )}
- </div>
- {/* 방송 정보 */}
- <div className="watch-page__info">
- <h1 className="watch-page__title">{channel.liveTitle || channel.name}</h1>
- <div className="watch-page__meta">
- <Link href={buildChannelUrl(channel)} className="watch-page__channel">
- {channel.thumbnailUrl ? (
- <img src={channel.thumbnailUrl} alt="" className="watch-page__avatar" />
- ) : (
- <span className="watch-page__avatar watch-page__avatar--default" aria-hidden="true">
- {channel.name.charAt(0)}
- </span>
- )}
- <span className="watch-page__channel-body">
- <span className="watch-page__channel-name">
- {channel.name}
- {channel.isVerified && (
- <span className="watch-page__verified" title="인증됨" aria-label="인증됨">✓</span>
- )}
- </span>
- <span className="watch-page__channel-sub">
- 구독자 {formatCount(channel.subscriberCount)}
- {channel.handle && <> · {formatHandle(channel.handle)}</>}
- {showViewers && (
- <>
- {' · '}
- <span className="watch-page__viewers" aria-label={`실시간 시청자 ${formatCount(viewerCount)}`}>
- <span className="watch-page__viewers-dot" aria-hidden="true" />
- 시청자 {formatCount(viewerCount)}
- </span>
- </>
- )}
- </span>
- </span>
- </Link>
- <div className="watch-page__actions">
- <FollowButton memberSID={channel.memberSID} className="watch-page__follow" />
- <ShareMenu title={shareTitle} />
- </div>
- </div>
- {channel.description && (
- <div className={`watch-page__desc ${descOpen ? 'watch-page__desc--open' : ''}`}>
- <p className="watch-page__desc-body">{channel.description}</p>
- <button
- type="button"
- className="watch-page__desc-toggle"
- onClick={() => setDescOpen((prev) => !prev)}
- aria-expanded={descOpen}
- >
- {descOpen ? '간략히' : '...더보기'}
- </button>
- </div>
- )}
- </div>
- </div>
- {/* 우측 채팅 — YouTube Live Chat iframe (라이브 시) */}
- <div className="watch-page__chat">
- <YouTubeChatIframe
- videoId={isLive ? videoId : null}
- />
- {/*
- * [DEPRECATED] antooza SignalR 채팅 — YouTube iframe 으로 대체.
- * quota 승인 후 재활성화 예정.
- * <ChatSidebar channelSID={channel.channelSID} />
- */}
- </div>
- </div>
- );
- }
|